今天目標會是先處理, EventModule 的關於資料儲存部份。
之前的實作中, EventsService 主要的職責是處理 Event 資訊的處理,其中關於存儲的部份是透過 EventStore 這個 InMemory 的實作來處理。當伺服器一但重新啟動,原本存儲的狀態就會消失了。
這邊會跟之前 UserModule 的 UsersService 類似,透過 Repository 這個對資料做操作的抽象介面,來替換真正存取資料庫的部份。
根據之前分析的 Entity 關係圖如下
今天會處理的是 Event 這個 Entity 。這邊會基於一些重要需要捕捉的屬性,來添加或減少 Entity 的欄位。
import { IsDate, IsNotEmpty, IsNumber, IsString, IsUUID } from 'class-validator';
import { Column, CreateDateColumn, Entity, PrimaryColumn, Unique, UpdateDateColumn } from 'typeorm';
@Unique('unique_event_condition', ['name', 'location', 'startDate'])
@Entity('events', { schema: 'public' })
export class EventEntity {
@PrimaryColumn({
type: 'uuid',
name: 'id'
})
@IsUUID()
id: string;
@Column({
unique: true,
type: 'varchar',
length: '200',
name: 'name'
})
@IsNotEmpty()
@IsString()
name: string;
@Column({
type: 'varchar',
length: '200',
name: 'location',
nullable: false,
})
@IsNotEmpty()
@IsString()
location: string;
@Column({
type: 'timestamp without time zone',
name: 'start_date',
nullable: false,
})
@IsDate()
startDate: Date;
@Column({
type: 'bigint',
name: 'number_of_days',
nullable: false,
default: 1,
})
@IsNumber()
numberOfDays: number = 1;
@CreateDateColumn({
type: 'timestamp without time zone',
name: 'created_at',
nullable: false,
default: 'now()',
})
@IsDate()
createdAt: Date;
@UpdateDateColumn({
type: 'timestamp without time zone',
name: 'updated_at',
nullable: false,
default: 'now()',
})
@IsDate()
updatedAt: Date;
}
npm run typeorm:create-migration --name=EVENT
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
export class EVENT1725356821827 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.createTable(
new Table({
schema: 'public',
name: 'events',
columns: [
{
name: 'id',
type: 'uuid',
isPrimary: true,
},
{
name: 'name',
type: 'varchar',
length: '200',
isNullable: false,
},
{
name: 'location',
type: 'varchar',
length: '200',
isNullable: false,
},
{
name: 'start_date',
type: 'timestamp without time zone',
isNullable: false,
},
{
name: 'number_of_days',
type: 'bigint',
default: 1,
isNullable: false,
},
{
name: 'created_at',
type: 'timestamp without time zone',
isNullable: false,
default: 'now()',
},
{
name: 'updated_at',
type: 'timestamp without time zone',
isNullable: false,
default: 'now()',
}
],
uniques: [{
name: 'unique_event_condition',
columnNames: ['name', 'location', 'start_date'],
}]
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropTable('public.events', true, true, true);
}
}
import { Injectable, NotFoundException } from '@nestjs/common';
import { CreateEventDto, PageInfoRequestDto, EventsResponse } from './dto/event.dto';
import { EventsRepository } from './events.repository';
import { EventEntity } from './schema/event.entity';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
@Injectable()
export class EventDbStore implements EventsRepository {
constructor(
@InjectRepository(EventEntity)
private readonly eventRepo: Repository<EventEntity>
) {}
async save(eventInfo: CreateEventDto): Promise<EventEntity> {
const event = new EventEntity();
event.id = crypto.randomUUID();
event.name = eventInfo.name;
event.location = eventInfo.location;
event.startDate = eventInfo.startDate;
if (eventInfo.numberOfDays) {
event.numberOfDays = eventInfo.numberOfDays;
}
await this.eventRepo.save(event);
return event;
}
async findOne(criteria: Partial<EventEntity>): Promise<EventEntity> {
const event = await this.eventRepo.findOneBy(criteria);
if (!event) {
throw new NotFoundException('event not found');
}
return event;
}
async find(criteria: Partial<EventEntity>, pageInfo: PageInfoRequestDto): Promise<EventsResponse> {
let queryBuilder = this.eventRepo.createQueryBuilder('events');
const offset = pageInfo.offset;
const limit = pageInfo.limit;
let whereCount = 0;
if (criteria.location) {
queryBuilder = (whereCount == 0)?
queryBuilder.where('events.location = :location', { location: criteria.location})
:queryBuilder.andWhere('events.location = :location', { location: criteria.location});
whereCount++;
}
if (criteria.name) {
queryBuilder = (whereCount == 0)?
queryBuilder.where('events.name = :name',{name: criteria.name})
:queryBuilder.andWhere('events.name = :name',{name: criteria.name});
whereCount++;
}
if (criteria.startDate) {
queryBuilder = (whereCount == 0)?
queryBuilder.where('events.start_date = :startDate', {startDate: criteria.startDate})
:queryBuilder.andWhere('events.start_date = :startDate', {startDate: criteria.startDate});
whereCount++;
}
queryBuilder = queryBuilder.offset(offset);
queryBuilder = queryBuilder.limit(limit);
queryBuilder = queryBuilder.orderBy('start_date', 'ASC');
const [events, total] = await queryBuilder.getManyAndCount();
return {
events,
pageInfo: {
total: total,
offset: pageInfo.offset,
limit: pageInfo.limit,
}
}
}
async update(criteria: Partial<EventEntity>, data: Partial<EventEntity>): Promise<EventEntity> {
const queryBuilder = this.eventRepo.createQueryBuilder('events');
const result = await queryBuilder.update<EventEntity>(EventEntity, data)
.where(criteria).updateEntity(true).execute();
const model: EventEntity = result.raw[0] as EventEntity;
return model;
}
async delete(criteria: Partial<EventEntity>): Promise<string> {
const queryBuilder = this.eventRepo.createQueryBuilder('events');
const result = await queryBuilder.delete().where(criteria).execute();
if (result.affected == 0) {
throw new NotFoundException('delete target not found');
}
return criteria.id;
}
}
import { Inject, Injectable } from '@nestjs/common';
import { EventsRepository } from './events.repository';
import { CreateEventDto, GetEventDto, GetEventsDto, PageInfoRequestDto, UpdateEventDto } from './dto/event.dto';
import { EventDbStore } from './event-db.store';
@Injectable()
export class EventsService {
constructor(
@Inject(EventDbStore)
private readonly eventRepo: EventsRepository
) {}
async createEvent(eventInfo: CreateEventDto) {
const result = await this.eventRepo.save(eventInfo);
return {id: result.id};
}
async getEvent(userInfo: GetEventDto) {
return this.eventRepo.findOne(userInfo);
}
async getEvents(criteria: GetEventsDto, pageInfo: PageInfoRequestDto) {
return this.eventRepo.find(criteria, pageInfo);
}
async updateEvent(eventId: string, updateData: UpdateEventDto) {
return this.eventRepo.update({id: eventId},updateData);
}
async deleteEvent(eventId: string) {
return this.eventRepo.delete({ id: eventId});
}
}
import { Module } from '@nestjs/common';
import { EventsService } from './events.service';
import { EventsController } from './events.controller';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { JwtAuthStrategy } from '../auth/strategies/jwt-auth.strategy';
import { UsersModule } from '../users/users.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { EventEntity } from './schema/event.entity';
import { EventDbStore } from './event-db.store';
@Module({
imports: [ UsersModule, TypeOrmModule.forFeature([EventEntity])],
providers: [EventsService, JwtAuthGuard, JwtAuthStrategy, EventDbStore],
controllers: [EventsController]
})
export class EventsModule {}
pnpm run test:e2e
pnpm run test:watch
這個指令,會檢查修改過的測試,重新執行一次。
註冊 event
存取特定 event
存取特定條件的 events
更新特定 event
刪除特定 event
開發到這裡,基本上把 user 跟 event 兩個重要關鍵狀態操作完成。接續下去就是去實行 Ticket 管理的行為。
以目前所開發的這些行為,基本上透過一些基礎的元件之間的互動,就可以完成。然而,如果需要一些更細緻狀態管理,比如說針對特定 Exception 需要做特殊的操作比如加特定的 error log 。就要額外的元件比如 ExceptionFilter 或是 Interceptor ,來做細緻流程管控。
因此,有了框架的幫助之後。軟體開發最重要的就是需求分析,接著把設計能夠符合需求的狀態管控。最後,再根據這些設計,逐一列出規格,來驗證系統行為。實作基本上都是前面步驟結束後才開始。
在沒有方向時,一定要先跟相關人員釐清需求。不論是使用哪一種方法論,確認大家俱備相同的理解後,再來做實踐。避免老是開發跟需求不一致。
開發出符合需求的軟體,才是叫作設計軟體。不然真的會變成碼農。